iT邦幫忙

0

[Day19]圖片批量轉檔 GUI(Tkinter + Pillow)

  • 分享至 

  • xImage
  •  

想把一整個資料夾的圖片一次轉成 JPG/PNG/WebP、順便縮圖/壓縮?
今天做一個純本機、零後端的小工具:

  • 支援來源:.jpg/.jpeg/.png/.webp/.bmp/.tif/.tiff
  • 輸出格式:keep / jpg / png / webp
  • 等比例縮圖(指定最長邊;0=不縮)
  • 品質(JPG/WebP)、PNG 自動最佳化
  • 保留 EXIF(JPG)
  • 保留子資料夾結構、或全部扁平化到同一輸出夾
  • 進度條、不凍結(背景執行),遇錯自動略過

安裝

pip install pillow

程式碼(存成 img_convert_gui.py)

# img_convert_gui.py — Day 19:圖片批量轉檔 GUI(Tkinter + Pillow)
from __future__ import annotations
import threading, os, sys, subprocess
from pathlib import Path
from tkinter import Tk, StringVar, IntVar, BooleanVar, filedialog, messagebox
from tkinter import ttk
from PIL import Image, ImageOps

SUPPORT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}

def parse_patterns(text: str) -> list[str]:
    # 以逗號或空白分隔;空字串代表全部
    toks = [t.strip() for t in text.replace(",", " ").split() if t.strip()]
    return toks or ["*"]

def list_images(src: Path, recursive: bool, patterns: list[str]) -> list[Path]:
    files: list[Path] = []
    for pat in patterns:
        glob = src.rglob if recursive else src.glob
        for p in glob(pat):
            if p.is_file() and p.suffix.lower() in SUPPORT:
                files.append(p)
    # 去重
    uniq, seen = [], set()
    for p in files:
        rp = p.resolve()
        if rp not in seen:
            uniq.append(p)
            seen.add(rp)
    return uniq

def resize_keep_ratio(img: Image.Image, max_side: int) -> Image.Image:
    if not max_side or max_side <= 0:
        return img
    w, h = img.size
    long_side = max(w, h)
    if long_side <= max_side:
        return img
    scale = max_side / long_side
    new_size = (int(w * scale), int(h * scale))
    return img.resize(new_size, Image.LANCZOS)

def rgb_on_white(im: Image.Image) -> Image.Image:
    """把有透明的圖疊在白底上再轉 RGB(避免轉 JPG 變黑邊/鋸齒)。"""
    if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info):
        rgba = im.convert("RGBA")
        bg = Image.new("RGB", rgba.size, (255, 255, 255))
        bg.paste(rgba, mask=rgba.split()[-1])
        return bg
    return im.convert("RGB")

def open_folder(path: Path):
    try:
        if sys.platform.startswith("win"): os.startfile(str(path))
        elif sys.platform == "darwin": subprocess.run(["open", str(path)])
        else: subprocess.run(["xdg-open", str(path)])
    except Exception:
        pass

class App:
    def __init__(self):
        self.root = Tk()
        self.root.title("圖片批量轉檔 (Day 19)")
        self.root.geometry("760x420")

        self.src = StringVar()
        self.dst = StringVar()
        self.patterns = StringVar(value="*.jpg, *.jpeg, *.png, *.webp")
        self.recursive = BooleanVar(value=True)
        self.preserve_tree = BooleanVar(value=True)

        self.max_side = IntVar(value=1280)
        self.fmt = StringVar(value="jpg")      # keep / jpg / png / webp
        self.quality = IntVar(value=85)
        self.keep_exif = BooleanVar(value=True)
        self.rename = BooleanVar(value=False)

        self.status = StringVar(value="等待開始…")
        self.prog = None

        self.build_ui()

    def build_ui(self):
        pad = {"padx": 8, "pady": 6}

        row = ttk.Frame(self.root); row.grid(row=0, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="來源資料夾").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.src).grid(row=0, column=1, sticky="we")
        ttk.Button(row, text="選擇…", command=self.pick_src).grid(row=0, column=2)

        row = ttk.Frame(self.root); row.grid(row=1, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="輸出資料夾").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.dst).grid(row=0, column=1, sticky="we")
        ttk.Button(row, text="選擇…", command=self.pick_dst).grid(row=0, column=2)

        row = ttk.Frame(self.root); row.grid(row=2, column=0, sticky="we", **pad); row.columnconfigure(1, weight=1)
        ttk.Label(row, text="過濾 patterns").grid(row=0, column=0, sticky="w")
        ttk.Entry(row, textvariable=self.patterns).grid(row=0, column=1, sticky="we")
        ttk.Checkbutton(row, text="包含子資料夾 (recursive)", variable=self.recursive).grid(row=0, column=2)
        ttk.Checkbutton(row, text="保留原子資料夾結構", variable=self.preserve_tree).grid(row=0, column=3, padx=(8,0))

        row = ttk.Frame(self.root); row.grid(row=3, column=0, sticky="we", **pad)
        ttk.Label(row, text="最長邊(px,0=不縮)").grid(row=0, column=0)
        ttk.Spinbox(row, from_=0, to=8000, textvariable=self.max_side, width=8).grid(row=0, column=1, padx=(4,12))
        ttk.Label(row, text="輸出格式").grid(row=0, column=2)
        ttk.Combobox(row, values=["keep","jpg","png","webp"], textvariable=self.fmt, width=8, state="readonly"
                     ).grid(row=0, column=3, padx=(4,12))
        ttk.Label(row, text="品質(1-100)").grid(row=0, column=4)
        ttk.Spinbox(row, from_=1, to=100, textvariable=self.quality, width=6).grid(row=0, column=5, padx=(4,12))
        ttk.Checkbutton(row, text="保留 EXIF(JPG)", variable=self.keep_exif).grid(row=0, column=6)
        ttk.Checkbutton(row, text="檔名加 _converted", variable=self.rename).grid(row=0, column=7, padx=(8,0))

        row = ttk.Frame(self.root); row.grid(row=4, column=0, sticky="we", **pad)
        ttk.Button(row, text="開始轉檔", command=self.start).grid(row=0, column=0, padx=(0,8))
        ttk.Button(row, text="開啟輸出資料夾", command=self.open_out).grid(row=0, column=1)

        row = ttk.Frame(self.root); row.grid(row=5, column=0, sticky="we", **pad); row.columnconfigure(0, weight=1)
        self.prog = ttk.Progressbar(row, length=700, mode="determinate", maximum=100)
        self.prog.grid(row=0, column=0, sticky="we")
        ttk.Label(row, textvariable=self.status).grid(row=1, column=0, sticky="w", pady=(4,0))

    def pick_src(self):
        d = filedialog.askdirectory(title="選擇來源資料夾")
        if d: self.src.set(d)

    def pick_dst(self):
        d = filedialog.askdirectory(title="選擇輸出資料夾")
        if d: self.dst.set(d)

    def open_out(self):
        dst = Path(self.dst.get().strip() or ".")
        open_folder(dst)

    def set_status(self, text: str):
        self.status.set(text)

    def set_progress(self, done: int, total: int):
        pct = 0 if total == 0 else int(done * 100 / total)
        self.prog["value"] = pct
        self.set_status(f"處理中:{done}/{total} ({pct}%)")

    def start(self):
        src = Path(self.src.get().strip())
        dst = Path(self.dst.get().strip())
        if not src.exists():
            messagebox.showerror("錯誤", "來源資料夾不存在"); return
        if not dst.exists():
            try: dst.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                messagebox.showerror("錯誤", f"無法建立輸出資料夾:\n{e}"); return

        pats = parse_patterns(self.patterns.get())
        recursive = self.recursive.get()
        files = list_images(src, recursive, pats)
        total = len(files)
        self.set_progress(0, total)
        if total == 0:
            self.set_status("找不到符合的圖片"); return

        t = threading.Thread(target=self.worker,
                             args=(files, src, dst, self.preserve_tree.get(),
                                   self.max_side.get(), self.fmt.get(), self.quality.get(),
                                   self.keep_exif.get(), self.rename.get()),
                             daemon=True)
        t.start()

    def worker(self, files: list[Path], src: Path, dst: Path, preserve_tree: bool,
               max_side: int, fmt: str, quality: int, keep_exif: bool, rename: bool):
        done = 0
        for fp in files:
            try:
                rel = fp.relative_to(src) if preserve_tree else fp.name
                if isinstance(rel, Path):
                    target_dir = (dst / rel.parent)
                else:
                    target_dir = dst
                target_dir.mkdir(parents=True, exist_ok=True)

                # 決定輸出副檔名
                out_fmt = fmt.lower()
                if out_fmt == "keep":
                    ext_out = fp.suffix.lower()
                else:
                    ext_out = ".jpg" if out_fmt in ("jpg", "jpeg") else (".png" if out_fmt == "png" else ".webp")

                stem = fp.stem + ("_converted" if rename else "")
                out_path = target_dir / f"{stem}{ext_out}"

                with Image.open(fp) as im:
                    # 矯正旋轉
                    try:
                        im = ImageOps.exif_transpose(im)
                    except Exception:
                        pass

                    # 等比例縮圖
                    im_resized = resize_keep_ratio(im, max_side)

                    save_im = im_resized
                    save_kwargs = {}

                    if ext_out in (".jpg", ".jpeg"):
                        save_im = rgb_on_white(im_resized)  # 去 alpha,白底
                        save_kwargs.update(dict(quality=int(quality), optimize=True, progressive=True))
                        if keep_exif and getattr(im, "info", {}).get("exif"):
                            save_kwargs["exif"] = im.info["exif"]

                    elif ext_out == ".png":
                        # PNG 無損,最佳化
                        if save_im.mode == "P":
                            save_im = save_im.convert("RGBA")  # 避免調色盤轉存失真
                        save_kwargs.update(dict(optimize=True))

                    elif ext_out == ".webp":
                        # 有透明就保留 RGBA,JPG 來源也可轉成失真有損 WebP
                        if save_im.mode not in ("RGB", "RGBA"):
                            save_im = save_im.convert("RGBA" if "A" in save_im.getbands() else "RGB")
                        save_kwargs.update(dict(quality=int(quality), method=4))
                        # 嘗試帶 EXIF(視 Pillow/來源支援度)
                        if getattr(im, "info", {}).get("exif"):
                            save_kwargs["exif"] = im.info["exif"]

                    save_im.save(out_path, **save_kwargs)

            except Exception as e:
                # 失敗就略過
                print(f"[跳過] {fp} -> {e}")

            done += 1
            self.root.after(0, self.set_progress, done, len(files))

        self.root.after(0, self.set_status, f"完成!已處理 {done} 張,輸出於:{dst}")

    def run(self):
        self.root.mainloop()

if __name__ == "__main__":
    App().run()

怎麼用

  1. python img_convert_gui.py
  2. 選來源 / 輸出資料夾
  3. 過濾填 *.jpg, *.png(可留空=全部;支援逗號或空白分隔)
  4. 設定「最長邊(px)」→ 0 表示不縮圖
  5. 選輸出格式(keep/jpg/png/webp)與品質(JPG/WebP 有效)
  6. 勾選是否保留 EXIF(只對 JPG 有用)、是否檔名加 _converted、是否保留子資料夾
  7. 點「開始轉檔」,看進度條到 100% 即完成

實作:
https://ithelp.ithome.com.tw/upload/images/20251002/20169368wAQwlyfhyP.png

常見問題

  • 透明背景變黑/鋸齒? 轉成 JPG 會去透明;程式會自動疊白底處理(rgb_on_white)。
  • EXIF 沒保留? 僅 JPG 可靠保留;PNG/WebP 的 EXIF 支援依版本而異。
  • 色彩或檔案很大? JPG/WebP 可調低 品質;PNG 天生無損,大小較大但更清晰。
  • 副檔名 vs 轉檔:這是真正轉檔(不是只改副檔名);輸出以你選的格式寫入。

今日小結
做出一個可視化的圖片批量轉檔器:JPG/PNG/WebP、縮圖/壓縮、EXIF、保留子資料夾、進度條。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言